Skip to content

Support payments for less than the total MPP value#4373

Open
TheBlueMatt wants to merge 9 commits intolightningdevkit:mainfrom
TheBlueMatt:2026-02-partial-mpp-payments
Open

Support payments for less than the total MPP value#4373
TheBlueMatt wants to merge 9 commits intolightningdevkit:mainfrom
TheBlueMatt:2026-02-partial-mpp-payments

Conversation

@TheBlueMatt
Copy link
Collaborator

In some uses of LDK we need the ability to send HTLCs for only a
portion of some larger MPP payment. This allows payers to make
single payments which spend funds from multiple wallets, which may
be important for ecash wallets holding funds in multiple mints or
graduated wallets which hold funds across a trusted wallet and a
self-custodial wallet.

This adds support for it both in the more manual send_payment flow as well as pay_for_bolt11_invoice. Adding support for BOLT 12 is left for a followup. cc @benthecarman

@ldk-reviews-bot
Copy link

ldk-reviews-bot commented Feb 2, 2026

👋 Thanks for assigning @valentinewallace as a reviewer!
I'll wait for their review and will help manage the review process.
Once they submit their review, I'll check if a second reviewer would be helpful.

@TheBlueMatt TheBlueMatt force-pushed the 2026-02-partial-mpp-payments branch 2 times, most recently from a4b00d4 to e8d40e1 Compare February 3, 2026 02:39
@codecov
Copy link

codecov bot commented Feb 3, 2026

Codecov Report

❌ Patch coverage is 83.20312% with 43 lines in your changes missing coverage. Please review.
✅ Project coverage is 86.01%. Comparing base (56f12c8) to head (db6aa28).
⚠️ Report is 10 commits behind head on main.

Files with missing lines Patch % Lines
lightning/src/routing/router.rs 48.83% 21 Missing and 1 partial ⚠️
lightning/src/ln/outbound_payment.rs 86.53% 11 Missing and 3 partials ⚠️
lightning/src/ln/channelmanager.rs 90.24% 2 Missing and 2 partials ⚠️
lightning/src/ln/onion_utils.rs 94.33% 2 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #4373      +/-   ##
==========================================
- Coverage   86.02%   86.01%   -0.02%     
==========================================
  Files         156      156              
  Lines      103046   103169     +123     
  Branches   103046   103169     +123     
==========================================
+ Hits        88646    88739      +93     
- Misses      11888    11910      +22     
- Partials     2512     2520       +8     
Flag Coverage Δ
tests 86.01% <83.20%> (-0.02%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@TheBlueMatt TheBlueMatt force-pushed the 2026-02-partial-mpp-payments branch 3 times, most recently from 93228cf to b6b973f Compare February 3, 2026 14:12
@TheBlueMatt TheBlueMatt added this to the 0.3 milestone Feb 4, 2026
@TheBlueMatt
Copy link
Collaborator Author

Opportunistically tagging this 0.3 since its rather important for some of our users (and its important for my wallet project!)

@ldk-reviews-bot
Copy link

🔔 1st Reminder

Hey @wpaulino! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

if let Some(proc_macro2::TokenTree::Group(group)) = ty_tokens {
let first_token = group.stream().into_iter().next();
if let Some(proc_macro2::TokenTree::Ident(ident)) = first_token {
if is_init && ident == "legacy" {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this also suppose to check for "custom"? Without doing so, I'm unable to use this for a legacy field where it needs to be read in order to update another field.

error[E0560]: struct `FundingTxInput` has no field named `_sequence`
   --> lightning/src/ln/funding.rs:120:6
    |
120 |     (3, _sequence, (custom, Sequence,
    |         ^^^^^^^^^ `FundingTxInput` does not have this field
    |
    = note: all struct fields are already assigned

For more information about this error, try `rustc --explain E0560`.
error: could not compile `lightning` (lib) due to previous error

This is what I tried in #4290.

diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs
index 3b7c0bedb..2de763118 100644
--- a/lightning/src/ln/funding.rs
+++ b/lightning/src/ln/funding.rs
@@ -117,7 +117,19 @@ pub struct FundingTxInput {
 
 impl_writeable_tlv_based!(FundingTxInput, {
        (1, utxo, required),
-       (3, _sequence, (legacy, Sequence, |input: &FundingTxInput| Some(input.utxo.sequence))),
+       (3, _sequence, (custom, Sequence,
+               |read_val: Option<Sequence>| {
+                       if let Some(sequence) = read_val {
+                               // Utxo contains sequence now, so update it if the value read here differs since
+                               // this indicates Utxo::sequence was read with the default_value
+                               let utxo: &mut Utxo = utxo.0.as_mut().expect("utxo is required");
+                               if utxo.sequence != sequence {
+                                       utxo.sequence = sequence;
+                               }
+                       }
+                       Ok(read_val)
+               },
+               |input: &FundingTxInput| Some(input.utxo.sequence))),
        (5, prevtx, required),
 });

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, no, grrrrrrr. I was using custom for the "we have a field we need to initialize, but we need to do so via some special logic" rather than what you really needed which is "we have removed this field and want to have some logic when reading its old TLV". I think what we actually need is a read method to legacy that works like the custom case but is ignored for field-initialization cases.

Copy link
Contributor

@jkczyz jkczyz Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about checking if the field started with an underscore? Too risky / non-obvious?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea, seems weird. legacy was kinda all about "item removed" whereas custom is about "item needs special handling". IMO it makes sense to have a method called on legacy after its read.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Opened #4390.

@wpaulino wpaulino requested review from valentinewallace and removed request for wpaulino February 5, 2026 19:05
Copy link
Contributor

@valentinewallace valentinewallace left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needs rebase, getting comments out so GH doesn't eat them

}
}

Ok(())
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a bunch of other parameters in PaymentParameters that we could be checking here. The method would get quite long though. Did you go over the options and decide not to verify them here?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah I was actually just doing the things that are in RouteParameters, I hadn't considered the PaymentParameters fields. I can do some validation of those too.

@TheBlueMatt TheBlueMatt force-pushed the 2026-02-partial-mpp-payments branch from b6b973f to 2abd54b Compare February 6, 2026 17:02
At various points we've been stuck in our TLV read/write variants
but just want to break out and write some damn code to initialize
a field and some more code to decide what to write for a TLV.

We added the write-side part of this with the `legacy` TLV
read/write variant, but its useful to also be able to specify a
function which is called on the read side.

Here we add a `custom` TLV read/write variant which calls a method
both on read and write to either decide what to write or to map a
read value (if any) to the final field.
When `OutboundPayments` calls the provided `Router` to fetch a
`Route` it passes a `RouteParameters` with a specific max-fee. Here
we validate that the `Route` returned sticks to the limits
provided, and also that it meets the MPP rules of not having any
single MPP part which can be removed while still meeting the
desired payment amount.
@TheBlueMatt TheBlueMatt force-pushed the 2026-02-partial-mpp-payments branch from 2abd54b to 3d72f76 Compare February 6, 2026 17:05
@TheBlueMatt
Copy link
Collaborator Author

Rebased and addressed feedback. Note that I noticed the CLTV delta stuff for trampoline is a bit wrong so that's not checked here.

@TheBlueMatt TheBlueMatt force-pushed the 2026-02-partial-mpp-payments branch from 3d72f76 to 1199749 Compare February 6, 2026 17:34
In some uses of LDK we need the ability to send HTLCs for only a
portion of some larger MPP payment. This allows payers to make
single payments which spend funds from multiple wallets, which may
be important for ecash wallets holding funds in multiple mints or
graduated wallets which hold funds across a trusted wallet and a
self-custodial wallet.

In order to allow for this, we need to separate the concept of the
payment amount from the onion MPP amount. Here we start this
process by adding a `total_mpp_amount_msat` field to
`RecipientOnionFields` (which is the appropriate place for a field
describing something in the recipient onion).

We currently always assert that it is equal to the existing fields,
but will relax this in the coming commit(s).

We also start including a payment preimage on probe attempts,
which appears to have been the intent of the code, but which did
not work correctly.

The bulk of the test updates were done by Claude.
In some uses of LDK we need the ability to send HTLCs for only a
portion of some larger MPP payment. This allows payers to make
single payments which spend funds from multiple wallets, which may
be important for ecash wallets holding funds in multiple mints or
graduated wallets which hold funds across a trusted wallet and a
self-custodial wallet.

In the previous commit we added a new field to
`RecipientOnionFields` to describe the total value of an MPP
payment. Here we start using this field when building onions,
dropping existing arguments to onion-building methods.
In some uses of LDK we need the ability to send HTLCs for only a
portion of some larger MPP payment. This allows payers to make
single payments which spend funds from multiple wallets, which may
be important for ecash wallets holding funds in multiple mints or
graduated wallets which hold funds across a trusted wallet and a
self-custodial wallet.

In the previous commits we moved the total-MPP-value we set in
onions from being manually passed through onion-building to passing
it via `RecipientOnionFields`. This introduced a subtle bug, though
 - payments which are retried will get a fresh
`RecipientOnionFields` built from the data in
`PendingOutboundPayment::Retryable`, losing any custom
total-MPP-value settings and causing retries to fail.

Here we fix this by storing the total-MPP-value directly in
`PendingOutboundPayment::Retryable`.
In some uses of LDK we need the ability to send HTLCs for only a
portion of some larger MPP payment. This allows payers to make
single payments which spend funds from multiple wallets, which may
be important for ecash wallets holding funds in multiple mints or
graduated wallets which hold funds across a trusted wallet and a
self-custodial wallet.

In the previous few commits we added support for making these
kinds of payments when using the payment methods which explicitly
accepted a `RecipientOnionFields`. Here we also add support for
such payments made via the `pay_for_bolt11_invoice` method,
utilizing the new `OptionalBolt11PaymentParams` to hide the
parameter from most calls.

Test mostly by Claude
@TheBlueMatt TheBlueMatt force-pushed the 2026-02-partial-mpp-payments branch from 1199749 to db6aa28 Compare February 6, 2026 17:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants